#!/usr/bin/env python3

import sys
import os
import subprocess
import argparse
import re
import shutil
import errno
import json

default_swi_program = "@SWIPL_EXECUTABLE@"

def test_input_path(args):
    return os.path.join(args.git_dir, "tests" , args.testcase[0])

def test_ground_path(args):
    return os.path.join(args.git_dir, "tools", "ooanalyzer", "tests" , args.testcase[0])

def test_output_path(args):
    return os.path.join(args.build_dir, "tools", "ooanalyzer", "tests" , args.testcase[0])

def create_test_output_dir(args):
    test_output_dir = os.path.dirname(test_output_path(args))
    if os.path.isdir(test_output_dir):
        return

    try:
        os.makedirs(test_output_dir)
    except OSError as exc:
        if exc.errno == errno.EEXIST and os.path.isdir(test_output_dir):
            pass
        else:
            raise

def report_error(args, context, msg):
    if args.interactive:
        print("%s" % msg)
    else:
        print("FAILED: %s %s %s" % (args.testcase[0], context, msg), file=sys.stderr)

def update_rules(args):
    #os.chdir(args.build_dir)
    # This is pretty hackish, but if this script is run without
    # parallelization, then it does the right thing.  If it is run
    # parallel and the oorules are already up-to-date that's ok as
    # well.  And if they're not up-to-date, then this causes us to
    # just fail in a different way. :-)
    #os.system('make oorules >/dev/null')
    pass

def run_ooanalyzer_test(args):
    create_test_output_dir(args)

    testname = args.testcase[0]

    update_rules(args)

    options = []
    options.append("--no-site-file")
    options.append("--no-user-file")
    options.append("--config=%s" % os.path.join(args.build_dir, "tests", "testconfig.yaml"))
    options.append("--verbose=4")
    # options.append("--log=\"PLOG(>=debug)\"")
    json_output_path = test_output_path(args) + ".rawjson"
    options.append("--json=%s" % json_output_path)
    prolog_fact_path = test_output_path(args) + ".facts"
    options.append("--prolog-facts=%s" % prolog_fact_path)
    prolog_results_path = test_output_path(args) + ".results"
    options.append("--prolog-results=%s" % prolog_results_path)

    tool_path = os.path.join(args.build_dir, "tools", "ooanalyzer", "ooanalyzer")

    cmd = [tool_path]
    cmd.extend(options)

    exe_path = test_input_path(args) + ".exe"
    cmd.append(exe_path)

    output_path = test_output_path(args) + ".output"
    cmd.append(">" + output_path)
    error_path = test_output_path(args) + ".error"
    cmd.append("2>" + error_path)

    cmd = ' '.join(cmd)
    if args.commands:
        print(cmd)

    if not args.review:
        rc = subprocess.call(cmd, shell=True)
        if rc != 0:
            if not args.quiet:
                report_error(args, "", "ooanalyzer execution rc=%d" % rc)
            sys.exit(1)

    symbols_path = test_input_path(args) + ".symbols"
    named_path = test_output_path(args) + ".named"
    dir = os.path.dirname(os.path.realpath(__file__))
    cmd = "%s/ooanalyzer-symbolizer.py %s %s -o %s" % (dir, symbols_path, output_path, named_path)
    if args.commands:
        print(cmd)

    rc = subprocess.call(cmd, shell=True)
    if rc != 0:
        if not args.quiet:
            report_error(args, "", "ooanalyzer-symbolizer rc=%d" % rc)
        sys.exit(1)

    gen_facts_path = test_output_path(args) + ".facts"
    sort_file(args, gen_facts_path)
    gen_results_path = test_output_path(args) + ".results"
    sort_file(args, gen_results_path)
    failed = compare_prolog_answers(args, gen_facts_path, gen_results_path)
    json_pretty_path = test_output_path(args) + ".json"
    failed += compare_json_answers(args, json_output_path, json_pretty_path)
    return failed

def sort_file(args, filename):
    cmd = "sort %s >%s.new && mv %s.new %s" % (filename, filename, filename, filename)
    if args.commands:
        print(cmd)

    if not args.review:
        rc = subprocess.call(cmd, shell=True)
        if rc != 0:
            if not args.quiet:
                report_error(args, "", "ooanalyzer sorting %s rc=%d" % (filename, rc))
            sys.exit(1)

def run_prolog_test(args):
    create_test_output_dir(args)
    ground_facts_path = test_ground_path(args) + ".facts"

    testname = args.testcase[0]

    update_rules(args)

    script = "ooprolog.pl"
    tool = "swi"

    if 'PHAROS_APPIMAGE_PATH' in os.environ:
        exe_path = os.environ['PHAROS_APPIMAGE_PATH']
        script_path = "ooprolog"
    else:
        exe_path = args.swi_path
        rules_path = os.path.join(args.git_dir, "share", "prolog", "oorules")
        script_path = os.path.join(rules_path, script)
        os.chdir(rules_path)

    # Separate outputs for each command so we can run both simultaneously.
    gen_results_path = test_output_path(args) + ".swiresults"
    prolog_json_path = test_output_path(args) + ".swirawjson"
    prolog_spew_path = test_output_path(args) + ".swioutput"

    redirection = ">%s 2>&1" % (prolog_spew_path,)
    prolog_cmd = ("%s %s --log-level 7 --facts %s --results %s --json %s %s" %
                  (exe_path, script_path,
                   ground_facts_path, gen_results_path, prolog_json_path,
                   redirection))

    if args.commands:
        print(prolog_cmd)

    failed = 0
    if not args.review:
        rc = subprocess.call(prolog_cmd, shell=True)
        if rc != 0:
            if not args.quiet:
                report_error(args, "", "swipl execution rc=%d" % (rc))
            failed += 1

    sort_file(args, gen_results_path)

    if failed == 0:
        failed += compare_prolog_answers(args, ground_facts_path, gen_results_path)

    pretty_path = test_output_path(args) + ".swijson"
    failed += compare_json_answers(args, prolog_json_path, pretty_path)

    return failed

def compare_json_answers(args, gen_json_path, pretty_json_path):
    testname = args.testcase[0]

    ground_json_path = test_ground_path(args) + ".json"
    gen_json_lines = open(gen_json_path, 'r').readlines()
    gen_json = json.loads('\n'.join(gen_json_lines))
    gen_pp_json = json.dumps(gen_json, sort_keys=True, indent=4, separators=(',', ': '))
    ground_json_lines = open(ground_json_path, 'r').readlines()
    ground_json = json.loads('\n'.join(ground_json_lines))
    ground_pp_json = json.dumps(ground_json, sort_keys=True, indent=4, separators=(',', ': '))

    fh = open(pretty_json_path, "w")
    json.dump(gen_json, fh, sort_keys=True, indent=4, separators=(',', ': '))
    fh.write('\n')
    fh.close()

    if args.commands:
        cmd = "diff %s %s" % (ground_json_path, pretty_json_path)
        print(cmd)

    if gen_pp_json != ground_pp_json:
        print("JSON files differ!")
        return 1

    return 0

def compare_prolog_answers(args, gen_fact_path, gen_results_path):
    testname = args.testcase[0]

    ground_fact_path = test_ground_path(args) + ".facts"
    ordered_gen_fact_lines = open(gen_fact_path, 'r').readlines()
    ground_fact_lines = set(open(ground_fact_path, 'r').readlines())

    ground_results_path = test_ground_path(args) + ".results"
    ordered_gen_results_lines = open(gen_results_path, 'r').readlines()
    ground_results_lines = set(open(ground_results_path, 'r').readlines())

    # We used to _assume_ that there were no duplicates in the facts
    # or results, but a choice point bug in Prolog produced duplicates
    # once, so we should probably check for both conditions.  And then
    # adding the facts check revealed duplicate facts as well. :-)
    duplicates = []
    gen_fact_lines = set()
    for line in ordered_gen_fact_lines:
        if line in gen_fact_lines:
            duplicates.append(line)
            if not args.quiet:
                report_error(args, "ooanalyzer", "duplicate fact '%s'" % (line.rstrip()))
        gen_fact_lines.add(line)

    gen_results_lines = set()
    for line in ordered_gen_results_lines:
        if line in gen_results_lines:
            duplicates.append(line)
            if not args.quiet:
                report_error(args, "ooanalyzer", "duplicate result '%s'" % (line.rstrip()))
        gen_results_lines.add(line)


    if args.commands:
        cmd = "diff %s %s" % (ground_fact_path, gen_fact_path)
        print(cmd)
        cmd = "diff %s %s" % (ground_results_path, gen_results_path)
        print(cmd)

    extra_facts =  gen_fact_lines - ground_fact_lines
    missing_facts = ground_fact_lines - gen_fact_lines
    extra_results = gen_results_lines - ground_results_lines
    missing_results = ground_results_lines - gen_results_lines

    # We could do a better job of this, but for right now, this seems reasonable.
    new_gen_facts = set()
    for fact in gen_fact_lines:
        new_gen_facts.add(re.sub('sv_[0-9]+', 'sv_xxx', fact))
    new_ground_facts = set()
    for fact in ground_fact_lines:
        new_ground_facts.add(re.sub('sv_[0-9]+', 'sv_xxx', fact))

    # A common occurrence is for the sv_hashes to change.
    if len(extra_facts) == len(missing_facts) and len(missing_facts) != 0:
        new_extra_facts =  new_gen_facts - new_ground_facts
        new_missing_facts = new_ground_facts - new_gen_facts
        if len(new_extra_facts) == 0 and len(new_missing_facts) == 0:
            if not args.quiet:
                print("WARNING: %s ooanalyzer-prolog facts symbolic values changed" % (testname), file=sys.stderr)
            extra_facts = new_extra_facts
            missing_facts = new_missing_facts

    if args.discard and len(extra_facts)+len(missing_facts) != 0:
        new_extra_facts =  new_gen_facts - new_ground_facts
        new_missing_facts = new_ground_facts - new_gen_facts

        if not args.quiet:
            print("WARNING: %s ooanalyzer-prolog facts symbolic values discarded" % (testname), file=sys.stderr)
        extra_facts = new_extra_facts
        missing_facts = new_missing_facts

    if args.verbose:
        for fact in sorted(extra_facts):
            report_error(args, "ooanalyzer facts", "+%s+" % fact.rstrip())
        for fact in sorted(missing_facts):
            report_error(args, "ooanalyzer facts", "-%s-" % fact.rstrip())
        for result in sorted(extra_results):
            report_error(args, "ooanalyzer results", "+%s+" % result.rstrip())
        for result in sorted(missing_results):
            report_error(args, "ooanalyzer results", "-%s-" % result.rstrip())

    total_errors = len(extra_facts) + len(missing_facts) + len(extra_results) + len(missing_results) + len(duplicates)
    if total_errors > 0:
        if not args.quiet:
            fmt = "missing-facts=%d extra-facts=%d missing-results=%d extra-results=%d duplicates=%d"
            msg = fmt % (len(missing_facts), len(extra_facts),
                         len(missing_results), len(extra_results), len(duplicates))
            report_error(args, "ooanalyzer prolog", msg)
            return 1
        else:
            return 1
    return 0

def accept_test(args):
    gen_facts_path = test_output_path(args) + ".facts"
    if args.prolog:
        gen_results_path = test_output_path(args) + ".swiresults"
        gen_json_path = test_output_path(args) + ".swijson"
    else:
        gen_results_path = test_output_path(args) + ".results"
        gen_json_path = test_output_path(args) + ".json"

    ground_facts_path = test_ground_path(args) + ".facts"
    ground_results_path = test_ground_path(args) + ".results"
    ground_json_path = test_ground_path(args) + ".json"

    if args.commands:
        print("cp %s %s" % (gen_facts_path, ground_facts_path))
        print("cp %s %s" % (gen_results_path, ground_results_path))
        print("cp %s %s" % (gen_json_path, ground_json_path))

    if not args.review:
        shutil.copyfile(gen_facts_path, ground_facts_path)
        shutil.copyfile(gen_results_path, ground_results_path)
        shutil.copyfile(gen_json_path, ground_json_path)

# ===========================================================================
# Globals for the OOanalyzer output fixup process
# ===========================================================================

# My goal here was to make the eyes bleed just a little bit less...
fixups = [
    # Just to save a little more space on the screen...
    ('public: ', ''),
    ('private: ', ''),
    # These are for when have std templates in the output...
    ('struct std::char_traits<char>', 'CHAR_TRAITS'),
    ('class std::allocator<char>', 'CHAR_ALLOC'),

    ('basic_streambuf<char, CHAR_TRAITS>', 'basic_char_streambuf'),
    ('basic_istream<char, CHAR_TRAITS>', 'basic_char_istream'),
    ('istreambuf_iterator<char, CHAR_TRAITS>', 'istreambuf_char_iter'),
    ('basic_ostream<char, CHAR_TRAITS>', 'basic_char_ostream'),
    ('ostreambuf_iterator<char, CHAR_TRAITS>', 'ostreambuf_char_iter'),
    ('basic_ios<char, CHAR_TRAITS>', 'basic_char_ios'),

    ('basic_string<char, CHAR_TRAITS, CHAR_ALLOC>', 'basic_char_string'),
    ('basic_stringbuf<char, CHAR_TRAITS, CHAR_ALLOC>', 'basic_char_stringbuf'),
    ('_String_iterator<char, CHAR_TRAITS, CHAR_ALLOC>', '_string_iter'),
    ('_String_const_iterator<char, CHAR_TRAITS, CHAR_ALLOC>', '_string_const_iter'),
    ('_String_val<char, CHAR_ALLOC>', '_string_val'),

    ('pair<class std::basic_char_string const, class std::basic_char_string>', 'string_pair'),
    ('less<class std::basic_char_string>', 'less<str>'),
    ('class std::allocator<struct std::string_pair>', 'STR_PAIR_ALLOC'),

    ('_Tmap_traits<class std::basic_char_string, class std::basic_char_string, struct std::less<str>, STR_PAIR_ALLOC, 0', '_Tmap_traits<str, str>'),

    ('map<class std::basic_char_string, class std::basic_char_string, struct std::less<str>, STR_PAIR_ALLOC>', 'map<str, str>'),

    ('_Tree_val<class std::_Tmap_traits<str, str>>', '_Tree_val<str, str>'),
    ('_Tree_const_iterator<class std::_Tree_val<str, str>>>', '_Tree_const_iter<str, str>'),
    ('_Tree_nod<class std::_Tmap_traits<str, str>>', '_Tree_nod<str, str>'),
    ('_Tree_unchecked_const_iterator<class std::_Tree_val<str, str> >', '_Tree_unch_iter<str, str>'),

    ('_Pair_base<class std::basic_char_string, class std::basic_char_string>', '_Pair_base<str, str>'),
    ]

regexp_replace = [
    ("complete(, .*seconds elapsed).", 1, ""),
    ("took (.*) seconds.", 1, "X"),
    ("Analyzing executable: (.*/)?tests", 1, ""),
    ("Loaded API database: (.*/)?", 1, ""),
    ("to JSON file:? '?(.*/)?tests", 1, "")
]
regexp_replace = [(re.compile(rx), n, r) for (rx, n, r) in regexp_replace]


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Run an OOAnalyzer test.')
    parser.add_argument('testcase', metavar='testcase', type=str,
                        nargs='+',
                        help='The testcase to run')
    parser.add_argument("-p", "--prolog",
                        action="store_true", default=False,
                        help="only run the prolog part of the test")
    parser.add_argument("-v", "--verbose",
                        action="store_true", default=False,
                        help="report verbose details of test")
    parser.add_argument("-q", "--quiet",
                        action="store_true", default=False,
                        help="suppress messages about failures")
    parser.add_argument("-a", "--accept",
                        action="store_true", default=False,
                        help="accept the changes in test results")
    parser.add_argument("-r", "--review",
                        action="store_true", default=False,
                        help="review changes but don't rerun the commands")
    parser.add_argument("-c", "--commands",
                        action="store_true", default=False,
                        help="print commands associated with test")
    parser.add_argument("-i", "--interactive",
                        action="store_true", default=False,
                        help="report differences without testcase prefixes")
    parser.add_argument("-d", "--discard",
                        action="store_true", default=False,
                        help="discard differences in symbolic value hashes")
    parser.add_argument("-b", "--build-dir",
                        type=str, default=os.getcwd(),
                        help="set the cmake build directory")
    parser.add_argument("-g", "--git-dir",
                        type=str, default="",
                        help="set the git root checkout directory")
    parser.add_argument("-w", "--swi-path",
                        type=str, default=default_swi_program,
                        help="The location of the swipl runtime")
    args = parser.parse_args()

    if args.git_dir == "":
        args.git_dir = os.path.dirname(args.build_dir)

    ground_facts_path = test_ground_path(args) + ".facts"
    if not os.path.exists(ground_facts_path):
        print("FAILED: Invalid test case '%s'" % (args.testcase[0]), file=sys.stderr)
        print("  No such file or directory: '%s'" % (ground_facts_path), file=sys.stderr)
        sys.exit(1)

    if args.accept:
        while len(args.testcase) > 0:
            accept_test(args)
            args.testcase.pop(0)
        sys.exit(0)

    failures = 0
    while len(args.testcase) > 0:
        if args.prolog:
            failures += run_prolog_test(args)
        else:
            failures += run_ooanalyzer_test(args)

        args.testcase.pop(0)

    sys.exit(failures)

# Local Variables:
# mode: python
# fill-column: 95
# comment-column: 0
# End:
